iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Modern Web

用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)系列 第 23

登入狀態要放哪?Astro 的 locals 與 Vue island 怎麼分工?

  • 分享至 

  • xImage
  •  

登入狀態由 server 的 middleware 判斷,結果放進
context.locals。同一個 request 後面的頁面或 Action 直接讀這份結果,不必各自實作 session 查詢;.astro
頁面若要把資料交給 Vue island,只傳畫面需要的初始狀態,不把 cookie、token 或完整 session 序列化進 props 與 HTML。

Day 22:用 Drizzle ORM + Turso 接上資料層後,feedback 已經存得進資料庫,但後端還得在每次 request 進來時辨認是誰送的。這份工作由 middleware 與
locals 接手。

一個 request 會經過哪些地方?

頁面第一次 render 和使用者點擊 island 後呼叫 Action,分別屬於兩個 HTTP request:

request A:開啟頁面
  ↓
middleware:讀 cookie、辨認使用者
  ↓
context.locals A:保存這次 request 的 user / session
  ↓
.astro page:用 Astro.locals 讀取,將最小 props 放進 HTML
  ↓
Vue island hydrate

使用者點擊
  ↓
request B:呼叫 Action
  ↓
middleware 再執行一次,建立 context.locals B
  ↓
Action:從 ctx.locals 做權限檢查,再讀寫 DB

可以把 locals 想成這次 request 隨身帶著的識別證。middleware 寫入後,同一個 request 後面的 route
handler 都能直接讀;request 一結束,這份 locals 也跟著消失。request A 與 request
B 各有自己的 locals,不會跨 request 保存資料,也不能取代 Pinia、資料庫或 session store。

middleware 先辨認這次 request

Astro 會從 src/middleware.ts 找具名匯出的 onRequest。app 裡的實作如下:

// src/middleware.ts
import { defineMiddleware } from "astro:middleware";

export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.user = null;
  context.locals.session = null;

  // 預渲染頁與沒有 cookie 的匿名 request 不需要查 session。
  if (context.isPrerendered || !context.request.headers.get("cookie")) {
    return next();
  }

  const { createAuth } = await import("./lib/auth");
  const auth = createAuth(context.url.origin);
  const data = await auth.api.getSession({
    headers: context.request.headers,
  });

  context.locals.user = data?.user ?? null;
  context.locals.session = data?.session ?? null;
  return next();
});

next() 會把 request 交給後面的 middleware 或 route。執行到 route 時,前面寫入的 usersession 還留在同一個
locals 物件裡。

這裡會先判斷是否需要查 session:沒有 cookie 就略過,匿名訪客不必為了確認「沒有登入」多查一次資料庫。遇到 build-time
render 時,context.isPrerendered 會讓 middleware 直接交給 next(),避免靜態頁在建置時碰到個人化資料。

createAuth()getSession() 的細節會在
Day 24:Astro 的驗證策略與 Better Auth說明;這裡只追蹤 session 查完後的去向。

locals 是單次 request 的容器

middleware 寫的是 context.locals.astro 頁面讀的則是 Astro.locals

---
export const prerender = false;

const user = Astro.locals.user;
---

<p>
  目前 server 看到的登入者:
  <strong>{user ? user.email : '(未登入)'}</strong>
</p>

context.localsAstro.locals 指向同一次 render 的 request context。API
route 或 Action 收到自己的 request 時,也會先經過 middleware,再從各自的 context 讀取該次 request 的 locals。Day 20:用 Endpoint 輸出 JSON 與 RSS
Day 21:Actions 與 Endpoints 怎麼選介紹過的 handler
context,現在多了這份由 middleware 先算好的 request 資料。

型別則放在 src/env.d.ts

import type { Auth } from "./lib/auth";

type SessionData = Auth["$Infer"]["Session"];

declare global {
  namespace App {
    interface Locals {
      user: SessionData["user"] | null;
      session: SessionData["session"] | null;
    }
  }
}

這樣 middleware 寫入、頁面讀取、Action 判斷時都有同一套型別。Astro 5 起不能用 context.locals = { ... }
整個替換物件,應該逐欄賦值,或用 Object.assign();原因之一是 integration 也可能在同一個物件上放資料。

登入狀態為什麼不能塞進預渲染頁?

這個 app 維持 Astro 預設的
output: 'static'。文章頁在 build 時先產生 HTML,但登入狀態要等訪客真的送出 request 才知道,因此需要 session 的頁面必須改成 on-demand
rendering:

---
export const prerender = false; const user = Astro.locals.user;
---
頁面 render 時機 能否讀目前訪客的 session
一般文章頁 build time 不能,當時還沒有這位訪客
登入/收藏頁 request time 能,middleware 可讀 cookie

判斷標準和
Day 15:動態路由的 SSG/SSR 分界相同:內容對所有人相同就預渲染;內容取決於這次 request,就改成 on-demand。

舊文章可能把「static 為主,部分頁面動態」寫成 output: 'hybrid'。這個選項從 Astro
5 起已移除。現在直接維持 static 預設,在個別 route 寫 prerender = false 即可。

server 只把畫面需要的狀態交給 island

server 能讀完整的 user 與 session,Vue island 只需要畫面用得到的部分。現有的 /demos/auth 是 on-demand
route;收到 request 後,.astro frontmatter 會先查出登入者與初始收藏狀態,再投影成兩個布林值:

---
const user = Astro.locals.user;

let initialFavorited = false; if (user) { // server 查 favorites,略去查詢細節 initialFavorited = true; }
---

<FavoriteButton client:load slug="day-24-auth" isLoggedIn="{!!user}" initialFavorited="{initialFavorited}" />

Vue island 收到的是 UI 需要的最小資料:

const props = defineProps({
  slug: { type: String, required: true },
  isLoggedIn: { type: Boolean, default: false },
  initialFavorited: { type: Boolean, default: false },
});

hydrated component 的 props 會被序列化到輸出的 <astro-island>
HTML。能序列化不代表適合傳到瀏覽器;cookie、token、完整 session 與不必要的 DB 欄位,都不應序列化進 island
props 或 HTML。敏感 cookie 還應設為 HttpOnly,避免 client JavaScript 讀取。

這兩種容器的規則不同:

容器 在哪裡使用 是否要能序列化
context.locals server 的單次 request 不必
hydrated island props 從 server 跨到 browser 必須,而且使用者看得到

早期 Astro 曾要求 locals 可序列化,現在已取消這項限制;island
props 仍然必須序列化。若混用兩條規則,不該離開 server 的資料可能會進到 HTML。

「server 先算好登入狀態,再傳 props」只適用於 on-demand
page,因為預渲染文章頁在 build 時還沒有目前訪客。靜態文章頁若要放收藏按鈕,可以讓 island
hydrate 後另查 session,並處理 loading/狀態切換。這個 capstone 目前只在 on-demand 的 /demos/auth
示範 server 先給初始狀態。

另一種做法:自己造一個 request 容器

locals 留在 server、不必序列化;island props 會跨到瀏覽器、必須序列化。實務上也有人把這兩種容器合成一個。

一個上線中的多語系品牌官網就採用這種設計,middleware 以 sequence() 串起三段:

// src/middleware/index.ts
import { sequence } from "astro:middleware";

export const onRequest = sequence(createRequestStore, setLocale, updateStore);

這三段都不寫 context.locals,整個專案也沒有用到 locals。專案改用自建的 request store:server 端以 Node 的
AsyncLocalStorage 包住每次 request,瀏覽器端則讀同一份資料的另一個副本。這份副本由 layout 送出去:

---
// layout 的 frontmatter const requestData = JSON.stringify(requestStore.get());
---

<script set:html="{`window.__PAGE_STATE__" ="${requestData};`}" />

這個設計允許寫入時標記不外流的欄位。store 內部維護一份排除名單,get()
交給 layout 序列化之前會先濾掉這些欄位,因此不會把所有 server 資料直接送到瀏覽器。

這種設計沿用 Nuxt 類框架的機制:server 算好狀態,再序列化成 payload 給 client。把熟悉的形狀搬過來,可以同時處理 server 取值與 island 取值。

代價是那份排除名單必須由人維護。兩種容器合成一個之後,「不外流」不再由結構保證:漏標一個欄位,它就會出現在 HTML 裡。locals
不必序列化,因為它從來不離開 server;要跨界的資料改走 props,序列化就會成為每次都得明確寫出的動作。

這個專案維持 output: 'static' 且沒有安裝 adapter,所以那整套 per-request 機制只在 astro build
期間執行過,線上不會有第二次。把專案複製出來實際跑 build 時,Node 還跳了一則 localStorage is not available
的警告,因為有元件在建置階段就碰了只有瀏覽器才有的 API。這跟前面「登入狀態為什麼不能塞進預渲染頁」是同一條界線:沒有 request,就沒有這次 request 的使用者。

island 可以顯示狀態,不能替 server 授權

isLoggedIn
能決定按鈕顯示「收藏這篇」或「登入以收藏」,但它只是 client 拿到的初始提示。使用者可以修改瀏覽器裡的任何資料,所以點擊送出的 Action
request 會再次經過 middleware,Action 再從該次 request 的 ctx.locals.user 判斷:

toggleFavorite: defineAction({
  input: z.object({ slug: z.string().min(1) }),
  handler: async ({ slug }, ctx) => {
    const user = ctx.locals.user;

    if (!user) {
      throw new ActionError({
        code: "UNAUTHORIZED",
        message: "請先登入才能收藏文章",
      });
    }

    // 通過 server 端授權後,才讀寫 favorites。
  },
});

頁面 request 先算初始 UI,可省掉 island hydrate 後為了第一次顯示而另查 session;Action
request 仍須重新辨認使用者,才能在 server 端授權資料操作。兩次 session 查詢分屬不同 request,不能共用同一份 locals。

build 產物也看得出這條分界

.nvmrc 使用 Node 24.16.0 跑 npm run build,Astro 回報 output: "static" 並完成建置。Day 23 文章產在
dist/client/blog/day-23-middleware-locals/index.html;需要登入狀態的 /demos/auth 沒有靜態 HTML,而是編進
dist/server/chunks/auth_*.mjs

同一個 app 裡,公開文章在 build 時完成;登入 demo 等 request 進來才 render。實際產物與前面的表格一致。

登入狀態到底放哪裡?

範圍 放置位置 用途
跨 request cookie+session store 讓下一次 request 還能辨認同一位使用者
單次 request context.locals 讓該次 request 的 route handler 不必重做 session 查詢
server → browser 最小、可序列化的 island props 只提供初始 UI;不可當授權依據

Vue island 負責互動,不負責授權。每次會讀寫資料的 request 都要交給 middleware 與 server
handler,以該次 request 的 locals 判斷權限。

版本基準:Astro
7,2026-07-23 查證。官方文件:MiddlewarelocalsisPrerenderedOn-demand renderingFramework component props


上一篇
內容站要開始存資料,資料庫怎麼接才不會被平台綁死?
下一篇
想幫內容站加登入和收藏,2026 的 Astro 該把驗證交給誰?
系列文
用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言